/**@target web */
import fs from 'node:fs';
import * as path from 'node:path';
import * as url from 'node:url';

// webpack doesn't defined properly types for all these internals
// needed for the plugin

import { injectWorkerRuntimeModule } from './AlphaTabWorkerRuntimeModule';
import { configureAudioWorklet } from './AlphaTabAudioWorklet';
import type { AlphaTabWebPackPluginOptions } from './AlphaTabWebPackPluginOptions';
import { configureWebWorker } from './AlphaTabWebWorker';
import type { webPackWithAlphaTab, webpackTypes } from './Utils';
import { injectWebWorkerDependency } from './AlphaTabWebWorkerDependency';
import { injectWorkletRuntimeModule } from './AlphaTabWorkletStartRuntimeModule';
import { injectWorkletDependency } from './AlphaTabWorkletDependency';

const WINDOWS_ABS_PATH_REGEXP = /^[a-zA-Z]:[\\/]/;
const WINDOWS_PATH_SEPARATOR_REGEXP = /\\/g;

const relativePathToRequest = (relativePath: string): string => {
    if (relativePath === '') {
        return './.';
    }
    if (relativePath === '..') {
        return '../.';
    }
    if (relativePath.startsWith('../')) {
        return relativePath;
    }
    return `./${relativePath}`;
};

const absoluteToRequest = (context: string, maybeAbsolutePath: string): string => {
    if (maybeAbsolutePath[0] === '/') {
        if (maybeAbsolutePath.length > 1 && maybeAbsolutePath[maybeAbsolutePath.length - 1] === '/') {
            // this 'path' is actually a regexp generated by dynamic requires.
            // Don't treat it as an absolute path.
            return maybeAbsolutePath;
        }

        const querySplitPos = maybeAbsolutePath.indexOf('?');
        let resource = querySplitPos === -1 ? maybeAbsolutePath : maybeAbsolutePath.slice(0, querySplitPos);
        resource = relativePathToRequest(path.posix.relative(context, resource));
        return querySplitPos === -1 ? resource : resource + maybeAbsolutePath.slice(querySplitPos);
    }

    if (WINDOWS_ABS_PATH_REGEXP.test(maybeAbsolutePath)) {
        const querySplitPos = maybeAbsolutePath.indexOf('?');
        let resource = querySplitPos === -1 ? maybeAbsolutePath : maybeAbsolutePath.slice(0, querySplitPos);
        resource = path.win32.relative(context, resource);
        if (!WINDOWS_ABS_PATH_REGEXP.test(resource)) {
            resource = relativePathToRequest(resource.replace(WINDOWS_PATH_SEPARATOR_REGEXP, '/'));
        }
        return querySplitPos === -1 ? resource : resource + maybeAbsolutePath.slice(querySplitPos);
    }

    // not an absolute path
    return maybeAbsolutePath;
};

const _contextify = (context: string, request: string): string => {
    return request
        .split('!')
        .map(r => absoluteToRequest(context, r))
        .join('!');
};

const makeCacheableWithContext = (fn: (text: string, request: string) => string) => {
    const cache = new WeakMap<object, Map<string, Map<string, string>>>();

    const cachedFn = (context: string, identifier: string, associatedObjectForCache?: object): string => {
        if (!associatedObjectForCache) {
            return fn(context, identifier);
        }

        let innerCache = cache.get(associatedObjectForCache);
        if (innerCache === undefined) {
            innerCache = new Map();
            cache.set(associatedObjectForCache, innerCache);
        }

        let cachedResult: string | undefined;
        let innerSubCache = innerCache.get(context);
        if (innerSubCache === undefined) {
            innerSubCache = new Map();
            innerCache.set(context, innerSubCache);
        } else {
            cachedResult = innerSubCache.get(identifier);
        }

        if (cachedResult !== undefined) {
            return cachedResult;
        }
        const result = fn(context, identifier);
        innerSubCache.set(identifier, result);
        return result;
    };

    cachedFn.bindContextCache = (
        context: string,
        associatedObjectForCache?: object
    ): ((identifier: string) => string) => {
        let innerSubCache: Map<string, string> | undefined;
        if (associatedObjectForCache) {
            let innerCache = cache.get(associatedObjectForCache);
            if (innerCache === undefined) {
                innerCache = new Map();
                cache.set(associatedObjectForCache, innerCache);
            }

            innerSubCache = innerCache.get(context);
            if (innerSubCache === undefined) {
                innerSubCache = new Map();
                innerCache.set(context, innerSubCache);
            }
        } else {
            innerSubCache = new Map();
        }

        const boundFn = (identifier: string): string => {
            const cachedResult = innerSubCache.get(identifier);
            if (cachedResult !== undefined) {
                return cachedResult;
            }
            const result = fn(context, identifier);
            innerSubCache.set(identifier, result);
            return result;
        };

        return boundFn;
    };

    return cachedFn;
};

const contextify = makeCacheableWithContext(_contextify);

/**
 * @public
 */
export class AlphaTabWebPackPlugin {
    private _webPackWithAlphaTab!: webPackWithAlphaTab;
    options: AlphaTabWebPackPluginOptions;

    constructor(options?: AlphaTabWebPackPluginOptions) {
        this.options = options ?? {};
    }

    apply(compiler: webpackTypes.Compiler) {
        // here we create all plugin related class implementations using
        // the webpack instance provided to this plugin (not as global import)
        // after that we use the helper and factory functions we add to webpack
        const webPackWithAlphaTab = {
            webpack: compiler.webpack,
            alphaTab: {} as any
        } satisfies webPackWithAlphaTab;

        if ('alphaTab' in compiler.webpack.util.serialization.register) {
            // prevent multi registration
            webPackWithAlphaTab.alphaTab = compiler.webpack.util.serialization.register.alphaTab;
        } else {
            (compiler.webpack.util.serialization.register as any).alphaTab = webPackWithAlphaTab.alphaTab;

            injectWebWorkerDependency(webPackWithAlphaTab);
            injectWorkerRuntimeModule(webPackWithAlphaTab);

            injectWorkletDependency(webPackWithAlphaTab);
            injectWorkletRuntimeModule(webPackWithAlphaTab);
        }

        this._webPackWithAlphaTab = webPackWithAlphaTab;

        this._configureSoundFont(compiler);
        this._configure(compiler);
    }

    private _configureSoundFont(compiler: webpackTypes.Compiler) {
        if (this.options.assetOutputDir === false) {
            return;
        }

        // register soundfont as resource
        compiler.options.module.rules.push({
            test: /\.sf2/,
            type: 'asset/resource'
        });
        compiler.options.module.rules.push({
            test: /\.sf3/,
            type: 'asset/resource'
        });
    }

    private _configure(compiler: webpackTypes.Compiler) {
        const pluginName = this.constructor.name;

        const cachedContextify = contextify.bindContextCache(compiler.context, compiler.root);

        compiler.hooks.thisCompilation.tap(pluginName, (compilation, { normalModuleFactory }) => {
            this._webPackWithAlphaTab.alphaTab.registerWebWorkerRuntimeModule(pluginName, compilation);
            this._webPackWithAlphaTab.alphaTab.registerWorkletRuntimeModule(pluginName, compilation);

            configureAudioWorklet(
                this._webPackWithAlphaTab,
                pluginName,
                this.options,
                compiler,
                compilation,
                normalModuleFactory,
                cachedContextify
            );
            configureWebWorker(
                this._webPackWithAlphaTab,
                pluginName,
                this.options,
                compiler,
                compilation,
                normalModuleFactory,
                cachedContextify
            );
            this._configureAssetCopy(this._webPackWithAlphaTab, pluginName, compiler, compilation);
        });
    }

    private _configureAssetCopy(
        webPackWithAlphaTab: webPackWithAlphaTab,
        pluginName: string,
        compiler: webpackTypes.Compiler,
        compilation: webpackTypes.Compilation
    ) {
        if (this.options.assetOutputDir === false) {
            return;
        }

        const options = this.options;
        compilation.hooks.processAssets.tapAsync(
            {
                name: pluginName,
                stage: this._webPackWithAlphaTab.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
            },
            async (_, callback) => {
                let alphaTabSourceDir = options.alphaTabSourceDir;
                if (!alphaTabSourceDir) {
                    try {
                        const isEsm = typeof import.meta.url === 'string';
                        if (isEsm) {
                            alphaTabSourceDir = url.fileURLToPath(import.meta.resolve('@coderline/alphatab'));
                        } else {
                            alphaTabSourceDir = require.resolve('@coderline/alphatab');
                        }

                        alphaTabSourceDir = path.resolve(alphaTabSourceDir, '..');

                        // walk up to package.json
                        while (alphaTabSourceDir) {
                            if (
                                await fs.promises
                                    .access(path.join(alphaTabSourceDir, 'package.json'), fs.constants.F_OK)
                                    .then(() => true)
                                    .catch(() => false)
                            ) {
                                // found package directory
                                alphaTabSourceDir = path.resolve(alphaTabSourceDir, 'dist');
                                break;
                            } else {
                                // reached root
                                const parent = path.resolve(alphaTabSourceDir, '..');
                                if (parent === alphaTabSourceDir) {
                                    alphaTabSourceDir = undefined;
                                } else {
                                    alphaTabSourceDir = parent;
                                }
                            }
                        }
                    } catch {
                        alphaTabSourceDir = compilation.getPath('node_modules/@coderline/alphatab/dist/');
                    }
                }

                let isValidAlphaTabSourceDir: boolean;

                if (alphaTabSourceDir) {
                    try {
                        await fs.promises.access(path.join(alphaTabSourceDir, 'alphaTab.mjs'), fs.constants.F_OK);
                        isValidAlphaTabSourceDir = true;
                    } catch {
                        isValidAlphaTabSourceDir = false;
                    }
                } else {
                    isValidAlphaTabSourceDir = false;
                }

                if (!isValidAlphaTabSourceDir) {
                    compilation.errors.push(
                        new this._webPackWithAlphaTab.webpack.WebpackError(
                            'Could not find alphaTab, please ensure it is installed into node_modules or configure alphaTabSourceDir'
                        )
                    );
                    return;
                }

                const outputPath = (options.assetOutputDir ?? compiler.options.output.path) as string;
                if (!outputPath) {
                    compilation.errors.push(
                        new this._webPackWithAlphaTab.webpack.WebpackError(
                            'Need output.path configured in application to store asset files.'
                        )
                    );
                    return;
                }

                async function copyFiles(subdir: string): Promise<void> {
                    const fullDir = path.join(alphaTabSourceDir!, subdir);

                    compilation.contextDependencies.add(path.normalize(fullDir));

                    const files = await fs.promises.readdir(fullDir, { withFileTypes: true });

                    await fs.promises.mkdir(path.join(outputPath!, subdir), { recursive: true });

                    await Promise.all(
                        files
                            .filter(f => f.isFile())
                            .map(async file => {
                                // node v20.12.0 has parentPath pointing to the path (not the file)
                                // see https://github.com/nodejs/node/pull/50976
                                const sourceFilename = path.join(file.parentPath ?? (file as any).path, file.name);
                                await fs.promises.copyFile(sourceFilename, path.join(outputPath!, subdir, file.name));
                                const assetFileName = `${subdir}/${file.name}`;
                                const existingAsset = compilation.getAsset(assetFileName);

                                const data = await fs.promises.readFile(sourceFilename);
                                const source = new webPackWithAlphaTab.webpack.sources.RawSource(data);

                                if (existingAsset) {
                                    compilation.updateAsset(assetFileName, source, {
                                        copied: true,
                                        sourceFilename
                                    });
                                } else {
                                    compilation.emitAsset(assetFileName, source, {
                                        copied: true,
                                        sourceFilename
                                    });
                                }
                            })
                    );
                }

                await Promise.all([copyFiles('font'), copyFiles('soundfont')]);

                callback();
            }
        );
    }
}
